| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182 |
- "use client";
- import { useEffect, useState } from "react";
- import Link from "next/link";
- interface OutputFile {
- filePath: string;
- fileSizeBytes: number;
- width: number;
- height: number;
- durationSeconds: number;
- }
- interface Job {
- id: string;
- status: "pending" | "running" | "completed" | "failed";
- input: {
- text: string;
- template: string;
- platform: string;
- ttsProvider: string;
- };
- createdAt: number;
- updatedAt: number;
- error?: string;
- outputFiles?: OutputFile[];
- }
- const STATUS_BADGE: Record<string, string> = {
- pending: "badge-pending",
- running: "badge-running",
- completed: "badge-completed",
- failed: "badge-failed",
- };
- const STATUS_LABEL: Record<string, string> = {
- pending: "Waiting",
- running: "Rendering...",
- completed: "Completed",
- failed: "Failed",
- };
- export default function JobDetailPage({ params }: { params: Promise<{ id: string }> }) {
- const [job, setJob] = useState<Job | null>(null);
- const [loading, setLoading] = useState(true);
- const [id, setId] = useState<string>("");
- useEffect(() => {
- params.then((p) => setId(p.id));
- }, [params]);
- useEffect(() => {
- if (!id) return;
- const load = () => {
- fetch(`/api/jobs/${id}`)
- .then((r) => r.json())
- .then((data: Job) => {
- setJob(data);
- setLoading(false);
- if (data.status === "pending" || data.status === "running") {
- setTimeout(load, 2000);
- }
- })
- .catch(() => setLoading(false));
- };
- load();
- }, [id]);
- if (loading) {
- return (
- <div style={{ padding: 48, textAlign: "center", color: "var(--text-muted)" }}>
- Loading...
- </div>
- );
- }
- if (!job) {
- return (
- <div style={{ padding: 48, textAlign: "center" }}>
- <p>Job not found</p>
- <Link href="/jobs"><button className="btn btn-secondary" style={{ marginTop: 16 }}>Back to Jobs</button></Link>
- </div>
- );
- }
- return (
- <div>
- <div className="page-header">
- <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
- <Link href="/jobs" style={{ color: "var(--text-muted)", fontSize: 14 }}>Jobs</Link>
- <span style={{ color: "var(--text-muted)" }}>/</span>
- <h1 style={{ fontSize: 22 }}>{job.id.slice(0, 8)}</h1>
- </div>
- <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}>
- <span className={`badge ${STATUS_BADGE[job.status]}`}>
- {job.status === "running" && (
- <span className="status-dot animate-pulse" style={{ background: "#60a5fa" }} />
- )}
- {STATUS_LABEL[job.status]}
- </span>
- <span style={{ fontSize: 13, color: "var(--text-muted)" }}>
- {new Date(job.createdAt).toLocaleString("zh-CN")}
- </span>
- </div>
- </div>
- {/* Config cards */}
- <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 24 }}>
- <div className="card">
- <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Template</div>
- <div style={{ fontWeight: 600 }}>{job.input.template}</div>
- </div>
- <div className="card">
- <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Platform</div>
- <div style={{ fontWeight: 600 }}>{job.input.platform}</div>
- </div>
- <div className="card">
- <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>TTS Provider</div>
- <div style={{ fontWeight: 600 }}>{job.input.ttsProvider}</div>
- </div>
- <div className="card">
- <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Input</div>
- <div style={{ fontWeight: 600 }}>JSON ({job.input.text.length} chars)</div>
- </div>
- </div>
- {/* Error */}
- {job.error && (
- <div className="card" style={{ borderColor: "var(--error)", marginBottom: 24 }}>
- <div style={{ color: "var(--error)", fontWeight: 600, marginBottom: 4 }}>Error</div>
- <pre style={{ fontSize: 13, whiteSpace: "pre-wrap", color: "var(--error)" }}>{job.error}</pre>
- </div>
- )}
- {/* Output files */}
- {job.status === "completed" && job.outputFiles && job.outputFiles.length > 0 && (
- <div className="card" style={{ marginBottom: 24 }}>
- <h3 style={{ marginBottom: 16 }}>Output</h3>
- {job.outputFiles.map((file, i) => (
- <div key={i}>
- {/* Video player */}
- <video
- controls
- style={{
- width: "100%",
- maxHeight: 400,
- borderRadius: 8,
- backgroundColor: "#000",
- marginBottom: 12,
- }}
- src={`/api/download?file=${encodeURIComponent(file.filePath)}`}
- />
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
- <div>
- <div style={{ fontWeight: 500, marginBottom: 4 }}>{file.filePath.split("/").pop()}</div>
- <div style={{ fontSize: 13, color: "var(--text-muted)" }}>
- {file.width}x{file.height} | {(file.fileSizeBytes / 1024 / 1024).toFixed(1)}MB
- </div>
- </div>
- <a
- href={`/api/download?file=${encodeURIComponent(file.filePath)}&download=1`}
- className="btn btn-primary"
- style={{ fontSize: 13 }}
- >
- Download
- </a>
- </div>
- </div>
- ))}
- </div>
- )}
- {/* Input text */}
- <div className="card">
- <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 8 }}>Input Text</div>
- <pre style={{ fontSize: 13, color: "var(--text-muted)", whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>
- {job.input.text.slice(0, 1000)}{job.input.text.length > 1000 ? "..." : ""}
- </pre>
- </div>
- </div>
- );
- }
|